从一道题说起

最近又有人问我下面这道题目,题目是这样的,首先是一个DOM结构如下:

<html>
<head></head>
<body>
    <div>1</div>
    <div>2</div>
    <div>3</div>
    <div>4</div>
    <div>5</div>
</body>
</html>

非常easy的dom结构,在来一小段js,如下:

var nodes = document.getElementsByTagName('div');
for(var i = 0,len = nodes.length; i < len; i++){
    nodes[i].onclick = function(){
        console.log(i);
    }
}

好了,问题来了,依次点击div,结果是多少?答案并不是1,2,3,4,5,而是点击任何一个div都会输出5.

分析

先来说下为什么最后执行的结果都是5.首先我们要明白,js中没有块级作用域,讲人话,就是js中不存在{}这种代码块的东西。各位估计会反驳我说,上面例子中不是明明白白的写的for(){}这种代码,怎么这边就开始说js不存在{}这种东西呢?我先举个C++的例子吧

int arr[] = {1,2,3,4,5};
vector<int> v = vector<int>(arr,arr+sizeof(arr)/sizeof(int));
for(int i = 0; i < v.size(); i++){
    std::cout << i << std::endl;
}

这么写是没有问题的,下面我再加点东西

int arr[] = {1,2,3,4,5};
vector<int> v = vector<int>(arr,arr+sizeof(arr)/sizeof(int));
for(int i = 0; i < v.size(); i++){
    std::cout << i << std::endl;
}
std::cout << i;

这么写,编译器直接就报错了。提示 error: use of undeclared identifier 'i',很显然,出了for循环的{}的大括号对应的作用域之后,i就会被自动销毁。那么JS呢,也是这样么?我们来看个例子

for(var i = 0;i< 5;i++){
    console.log(i);
}
console.log(i);

这段代码执行结果是0,1,2,3,4,5.估计有人也会比较奇怪。这边我解释下JS执行这段代码的过程。
首先是变量提升,js把var i = 0;分解成两句话,var i;i =0;并且把var i;提到最近一个function的顶部,这个时候,这段代码就变成了这样

var i;
for(i=0;i<5;i++){
    console.log(i);
}
console.log(i);

这样各位对于上面执行出来的0,1,2,3,4,5估计就没啥疑问了。
看完这个例子之后,我也希望各位注意下我前面说的js没有块级作用域,以及js会做变量提升,把变量的申明提升到最近的一个function的顶部
由于js会做变量提升,自动将变量的申明提升到最近的一个function的顶部,所以{}根据不会构成所谓的块级作用域,对js里面的变量而言,只有function才会是其作用域。

好了,讲完js的变量提升,我们再回头来看最开始的这个问题。首先是变量提升,提升之后我们得到

var nodes = document.getElementsByTagName('div');
var i;
for(i = 0,len = nodes.length; i < len; i++){
    nodes[i].onclick = function(){
        console.log(i);
    }
}

执行过程中,我们对每个node[i]节点都绑定了一个onclick事件,但是for循环执行的过程中,我们并没有出发这个click事件,for循环执行结束之后,i变为5。当用户点击div的时候,这个时候执行对应的onclick函数,也就是console.log(i),这个时候,会自动找到被js变量提升过的i,所以大家都会输出5.

解决

总结下,上面的问题之所以会产生,就是因为所有的onclick事件都去引用被js变量提升的i,那么如果我们想要解决这个问题,应该怎么办呢。一个就是我们可以通过JS的IIFE(immediately-invoked-function-expression)来构造一个作用域,让onclick函数引用我们构造出来作用域里面的i。ok,我们来解决下

var nodes = document.getElementsByTagName('div');
for(var i = 0,len = nodes.length; i < len; i++){
    (function(i){
        nodes[i].onclick = function(){
            console.log(i);
        }
    })(i)
    
}

这种做法把整个绑定事件的过程都给包起来了,由于IIFE会马上执行,for循环的i相当于一个输入参数,在绑定完事件只有,也形成了一个作用域,并且这个作用域中存在一个i的值。

同样的道理,我再给一种解法,如下:

var nodes = document.getElementsByTagName('div');
for(var i = 0,len = nodes.length; i < len; i++){
    nodes[i].onclick = (function(i){
        return function(){
            console.log(i);
        }
    })(i)
}

除此之外,我们可能会想到,如果js能够有这种块级作用于就好了,我们绑定的事件一定是在{}作用域下面,一定可以引用到for循环中的每个i,而不是应用哪个被变量提升的i。ES6提出了用let关键字来代替var关键字,具体的话可以参考阮一峰的而ES6教程。上个代码,这边代码用了一个inbrowser的es6转码器,可以测试用,如果想要生产环境中使用需要提前将es6代码编译成es5的代码。

<!DOCTYPE html>
<html>
<head>
</head>
<body>
    <div>1</div>
    <div>2</div>
    <div>3</div>
    <div>4</div>
    <div>5</div>
   <script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
   <script type="text/babel">
        let nodes = document.getElementsByTagName('div');
        console.log('exec');
        for(let i = 0,len = nodes.length; i < len; i++){
            nodes[i].onclick = function(){
                    console.log(i);
                }
        }
   </script>>
</body>
</html>

<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>引用了一个inbrower级别的es6转码器。具体可以参考babel-standalone项目.改进后的代码与原来的代码的区别在于,将var i = 0换成了let i = 0.
下面我在看下,通过转码之后,到底生成了什么样的js代码,通过es6转码器,我们最终生成了如下的代码

var nodes = document.getElementsByTagName('div');

var _loop = function _loop(i, len) {
    nodes[i].onclick = function () {
        console.log(i);
    };
};

for (var i = 0, len = nodes.length; i < len; i++) {
    _loop(i, len);
}

原来ES6帮我们构造了一个function的作用域报过了node[i].onclick的事件绑定过程,跟我们上面的解决方法其实是一样的!


warjiang
572 声望14 粉丝